Skip to content

fix(telegram): escape dollar signs and keep line breaks in rich messages - #1113

Open
Gourav224 wants to merge 1 commit into
vercel:mainfrom
Gourav224:fix/telegram-rich-message-escaping
Open

Gourav224 wants to merge 1 commit into
vercel:mainfrom
Gourav224:fix/telegram-rich-message-escaping

Conversation

@Gourav224

Copy link
Copy Markdown

Since 4.41, { markdown } and { ast } posts go through sendRichMessage with the markdown passed unchanged. Telegram's rich-message parser differs from CommonMark in two ways that break ordinary text:

  • $...$ is inline math, so MU ~$950 (-3.3% from $983.23) renders as a formula, and % inside it starts a TeX comment that drops the rest.
  • A single newline is a soft break, so line-per-item text like • a\n• b collapses into one paragraph. The MarkdownV2 path kept those lines.

This adds escapeRichMarkdown() to the Telegram adapter. It parses the markdown and rewrites only the source of text nodes: $ becomes \$, and each soft line ending gets a trailing backslash so Telegram renders a hard break. The issue reports that both forms render correctly in sendRichMessage. Inline code, code blocks, link URLs, and autolinks keep their source. Posts, edits, and streamed drafts and final messages all use it, and the rich-message length checks count the escaped text.

Markdown that used $...$ on purpose to render a formula now shows the dollar signs, so the Telegram docs and README no longer list formulas as a rich-message feature.

Tests

New unit tests cover dollar amounts, $ inside inline and fenced code, existing escapes and hard breaks, and line breaks in paragraphs, block quotes, and list items, plus an adapter test that checks the markdown sent to sendRichMessage. That adapter test fails without the change.

  • @chat-adapter/telegram vitest: 524 passed
  • biome check on the changed files and tsc --noEmit for the adapter: clean

Fixes #1109

🤖 Generated with Claude Code

Since 4.41, `{ markdown }` and `{ ast }` posts go through
`sendRichMessage` with the markdown passed unchanged. Telegram's
rich-message parser reads `$...$` as inline math, so a message such as
`MU ~$950 (-3.3% from $983.23)` rendered as a formula, and `%` inside
it started a TeX comment that dropped the rest. The parser also treats
a single newline as a soft break, so line-per-item text collapsed into
one paragraph.

`escapeRichMarkdown()` parses the markdown and rewrites only the source
of text nodes: it escapes `$` and ends each soft-wrapped line with a
backslash hard break. Inline code, code blocks, link URLs, and
autolinks keep their source. Posts, edits, and streamed drafts and
final messages all use it, and length checks count the escaped text.

Markdown that relied on `$...$` for a formula now shows the dollar
signs, so the docs no longer list formulas as a rich-message feature.

Fixes vercel#1109

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Gourav Kumar Dhiman <gkrdhiman@gmail.com>
@Gourav224
Gourav224 requested a review from a team November 1, 2026 13:09
@vercel

vercel Bot commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

@Gourav224 is attempting to deploy a commit to the Vercel Team on Vercel.

A member of the Team first needs to authorize it.

Comment on lines +48 to +50
if ("children" in node) {
for (const child of node.children) {
collectTextRanges(child, markdown, ranges);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Escaping dollar signs or soft newlines inside shortcut and collapsed reference-link labels changes their lookup identifiers, causing valid links to become literal text instead of resolving to their definitions.

Fix on Vercel

@roboeve roboeve Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for picking this up. The core approach (rewrite only the source of mdast text nodes) is sound, and most of the edge cases I threw at it behave well. Two things need to change before merge, plus a few smaller suggestions inline.

What I checked

  • Checked out aa405b44, ran the full @chat-adapter/telegram suite (524 passed), tsc --noEmit and biome check on the changed files: all clean.
  • Probed escapeRichMarkdown with ~35 inputs: existing escapes, \$, code spans and fences, ```math, tables, footnotes, task lists, setext headings, lazy blockquote continuation, inline HTML, entities, bare URLs, emails, www. links, emoji placeholders, and AST input via stringifyMarkdown. Output is idempotent (escape(escape(x)) === escape(x)) for every case, and code, autolinks, and existing hard breaks are preserved.
  • Mutation testing against rich.test.ts + index.test.ts:
Mutant Result
Drop $ escaping killed
Drop newline to hard-break branch killed
Drop \\[\s\S] (existing escape) alternative killed
Drop the autolink skip killed
Remove escapeRichMarkdown from renderRichMarkdown killed
Remove escaping from the streamed draft (sendRichMessageDraft) survived
Remove escaping from the streamed final message survived
\r?\n changed to \n survived
  • I could not run a live smoke test against the Bot API (no bot token available), so Telegram-side rendering is verified only against the Bot API rich markdown docs, not end to end.

Requested changes

  1. Streaming paths are untested. The PR description says streamed drafts and final messages are escaped, but both escapeRichMarkdown calls in stream() can be deleted with the whole suite still green. Please add a stream test that asserts the sendRichMessageDraft and final sendRichMessage bodies contain \$ and a backslash before each newline. See the inline comments.

  2. Shortcut and collapsed reference links break (confirming the existing review comment on rich.ts). The label of [label] / [label][] is both the link text and the lookup key, and CommonMark matches labels on raw source, so escaping it detaches the link from its definition. Reproduced:

"[$5]\n\n[$5]: https://x.com"   linkReference -> text   (output "[\\$5]")
"[$5][]\n\n[$5]: https://x.com" linkReference -> text
"[a\nb]\n\n[a b]: https://x.com" linkReference -> text, break, text

Full references ([$5][ref]) are fine. Simplest fix: in collectTextRanges, don't descend into linkReference / imageReference nodes whose referenceType is not "full". Alternatively rewrite them to full references ([\$5][$5]) so the text is escaped and the label still resolves. Please add regression tests for both shortcut and collapsed forms.

Non-blocking

  • <details> HTML blocks are skipped. The Bot API docs say Markdown is parsed inside <details>, <tg-collage> and <tg-slideshow>, but remark treats <details><summary>Price</summary>\nIt costs $5 and $6\n</details> as one html node, so neither the $ nor the line breaks inside it are touched. Content separated from the tags by blank lines is handled correctly. Worth either handling or calling out in the docs, since the docs list details as a rich-message feature.
  • Split-mode cost. With longMessages: "split", every fits() probe in splitLongMessage now re-parses the stringified part. On a 118 KB, 4,000-line list the post went from ~4.2 s to ~9.4 s locally (default truncate mode on 28 KB: ~400 ms to ~560 ms). Not a blocker, but a cheap fast path or computing the escaped length without a full parse would help.

let draftText: string;
if (streamUsesRich) {
draftText = truncateRichMarkdown(renderer.render());
draftText = truncateRichMarkdown(escapeRichMarkdown(renderer.render()));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing escapeRichMarkdown here keeps all 524 tests passing. Please cover the draft path, e.g. stream "MU ~$950\nTSLA ~$403" and assert the sendRichMessageDraft body's markdown is "MU ~\\$950\\\nTSLA ~\\$403".


if (streamUsesRich) {
const markdown = truncateRichMarkdown(finalMarkdown);
const markdown = truncateRichMarkdown(escapeRichMarkdown(finalMarkdown));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here: removing the escape on the final streamed sendRichMessage is not caught by any test. The same stream test can assert the final body too.

const MARKDOWN_PUNCTUATION = /[!-/:-@[-`{-~]/g;
const LINE_BREAKS = /[\r\n]/g;
const BACKTICKS = /`+/g;
const RICH_TEXT_SPECIALS = /\\[\s\S]|\$|\r?\n/g;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: CRLF handling is untested (changing \r?\n to \n survives the suite), and a bare \r line ending, which CommonMark also treats as a line ending, isn't converted ("a\rb" comes back unchanged). Worth a "a\r\nb" test, and either handling \r or noting it's intentionally unsupported.

## Markdown formatting

On Telegram Bot API 10.1 and newer, explicit `{ markdown }` and `{ ast }` messages use rich messages. Standard markdown gains native headings, lists, tables, task lists, formulas, details, and separate media blocks where supported by Telegram.
On Telegram Bot API 10.1 and newer, explicit `{ markdown }` and `{ ast }` messages use rich messages. Standard markdown gains native headings, lists, tables, task lists, details, and separate media blocks where supported by Telegram. The adapter escapes `$` outside code so Telegram doesn't read it as a formula delimiter, and turns single line breaks into hard breaks so each line stays on its own line, as it does in the MarkdownV2 path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: formulas aren't actually gone. Fenced ```math blocks are code nodes, so they pass through unescaped, and the Bot API renders them as formulas (as does inline <tg-math>). Consider keeping formulas in the list and saying they are written with a ```math block rather than $...$. Same for the README.

## Markdown formatting

On Telegram Bot API 10.1 and newer, explicit `{ markdown }` and `{ ast }` messages use rich messages, including native headings, lists, tables, task lists, formulas, details, and separate media blocks supported by the Bot API.
On Telegram Bot API 10.1 and newer, explicit `{ markdown }` and `{ ast }` messages use rich messages, including native headings, lists, tables, task lists, details, and separate media blocks supported by the Bot API. The adapter escapes `$` outside code so Telegram doesn't read it as a formula delimiter, and turns single line breaks into hard breaks so each line stays on its own line, as it does in the MarkdownV2 path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same note as the docs page: formulas still work via ```math fenced blocks.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

telegram: rich messages render $…$ as math and collapse single newlines

1 participant